/*
 * Licensed to the Tai Ping Jin Ke
 *
 * Copyright (c) 2022 .
 * All rights reserved.
 * 项目名称：太平金科-基础组件封装层-会话管理-缺省实现
 * 版权说明：本软件属太平金融科技服务(上海)有限公司所有，在未获得太平金融科技服务(上海)有限公司正式授权情况下，任何企业和个人，不能获取、阅读、安装、传播本软件涉及的任何受知识产权保护的内容。
 */
package com.taipingframework.boot.session;

import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import com.taipingframework.boot.cache.core.CommonCacheHandler;
import com.taipingframework.boot.cache.redis.DynamicCacheKeyBuilder;
import com.taipingframework.boot.cache.redis.StandardCacheKey;
import com.taipingframework.boot.session.constant.SessionCachePathEnum;
import com.taipingframework.boot.session.constant.SessionConstant;
import com.taipingframework.boot.session.core.CommonSessionHandler;
import com.taipingframework.boot.session.support.ClientDetails;
import com.taipingframework.boot.web.response.status.ExceptionStatusEnum;
import com.taipingframework.utility.constant.ApplicationConstant;
import com.taipingframework.utility.exception.SystemInterrupterException;
import com.taipingframework.utility.exception.UtilityException;
import com.taipingframework.utility.extend.ServletHelper;
import com.taipingframework.boot.web.response.status.ApiStatusEnum;
import com.taipingframework.utility.extend.StringHelper;
import com.taipingframework.utility.thread.threadlocal.TransmittableThreadLocalHelper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

/**
 * request.getSession(false)的解读：
 * 1.如果客户端浏览器的Cookie中：没有存放SessionID，那么，服务器端 也不会 主动创建Session；
 * 2.如果客户端浏览器的Cookie中：存放了SessionID，在服务器端没有找到对应的Session，那么，服务器端 也不会 主动创建Session；
 * 3.在分布式集群环境下，通过这种方式，使服务器端不会主动创建Session，也就不会产生新的SessionID，
 * 这样，在相同Cookie存放路径下，可以避免新的SessionID覆盖客户端浏览器的Cookie中已经存放的SessionID。
 * <p>
 * ● 请注意，当前声明类型的Bean每次HTTP请求都要求重新创建。在改动时，务必注意 类变量 容易发生 线程安全性问题。
 */
@Slf4j
public final class DefaultSessionHandler extends OriginalSessionHandler implements CommonSessionHandler {

    public DefaultSessionHandler(CommonCacheHandler cacheHandler,
                                 ObjectMapper objectMapper) throws UtilityException {
        super(cacheHandler, objectMapper);
    }

    /**
     * 创建Session [登录]
     * <p>
     * 特别说明：
     * `在会话有效期内使用了错误的身份凭证`这个错误消息针对的是在权限管理模块开放了白名单的登录接口，
     * 向前端抛出这个错误消息的目的是为了限制客户端使用相同的账号重复创建多个不同的会话对象！
     * <p>
     * 登录接口的特殊性：
     * 登录接口加入白名单后，那么在调用该接口时不要求提供token参数，
     * 但是，如果客户端已经持有token，则在多次重复调用登录接口时，应该提供入参token；
     * 否则，抛出错误消息：`在会话有效期内使用了错误的身份凭证`。
     * <p>
     * 个人理解：
     * 正常情况下，在权限管理模块应该只能允许“登录接口”被加入白名单，
     * 而如果存在其它被加入到白名单的接口，只能被允许提供读取数据的权限，禁止写入数据！
     * <p>
     * 常规方案：
     * 排除登录接口，针对其它被加入到白名单的接口，正确做法是作为open-api面向网络开发，在调用链中不会经过权限控制的一系列filter。
     * 另外，针对网络接口，在被客户端重复调用时，应该充分考虑数据的幂等性问题并给予解决，避免数据冗余！
     */
    @Override
    public HttpSession createSession(@NonNull String loginName) throws UtilityException {
        try {
            // 获取当前HTTP请求对象
            HttpServletRequest request = ServletHelper.getRequest();
            // 读取客户端发送HTTP请求的参数详细
            ClientDetails clientDetails = HttpSessionManager.readClientDetails(request, loginName);

            // 校验当前登录名在缓存服务器是否已经存在会话对象数据
            if (HttpSessionManager.validateClientInfo(clientDetails)) {
                log.debug("终端显示层【{}】会话对象未失效，不会重复创建会话对象", clientDetails.getClientIpAddress());
                try {
                    return super.getHttpSession(request);
                } catch (Exception ignored) {
                    // 读取缓存服务器的会话数据
                    String realSessionId = HttpSessionManager.readCurrentTerminalSession(loginName, clientDetails.getClientIpAddress(), clientDetails.getClientUserProfile());
                    // 拿到缓存中的会话对象标识，打印异常日志
                    log.error("终端显示层【{}】会话对象在服务器已经存在，但是获取失败。关键参数：(1)请求头携带的会话标识ID是：{}；(2)实际应该是：{}",
                            clientDetails.getClientIpAddress(), clientDetails.getRequestHeaderToken(), realSessionId);

                    // 重新包装异常消息，给出符合应用场景的异常报文
                    throw new SystemInterrupterException(ExceptionStatusEnum.INCORRECT_TOKEN_FROM_CLIENT);
                }
            } else {
                try {
                    // 开始创建会话对象
                    HttpSession httpSession = super.createHttpSession(request);

                    // 会话对象创建成功后，将登陆名与会话对象绑定
                    httpSession.setAttribute(SessionConstant.SESSION_LOGIN_KEY, loginName);
                    // 会话对象创建成功后，将用户空间与会话对象绑定
                    httpSession.setAttribute(SessionConstant.CLIENT_USER_PROFILE_KEY, clientDetails.getClientUserProfile());
                    // 记录当前会话对象映射的登录名【在spring-web会话管理模块DefaultSessionHandler.java中退出登录成功时获取】
                    TransmittableThreadLocalHelper.getEnvironment().putIfAbsent(SessionConstant.SESSION_LOGIN_KEY, loginName);

                    // 更新缓存的value
                    HttpSessionManager.saveOrUpdateCurrentTerminalSession(clientDetails, httpSession.getId());

                    log.debug("将会话标识【{}】与终端显示层所在设备的真实IP【{}】进行绑定，并将该映射关系保存在redis缓存中",
                            httpSession.getId(), clientDetails.getClientIpAddress());

                    return httpSession;
                } catch (Exception ex) {
                    // 更优雅地书写思虑周全的代码往往是很困难的，但是，java中的异常就像幽灵似的神出鬼没，它还没出现但却在暗处找机会等着给你惊喜而已！
                    // 如果会话对象创建成功但在执行后续的其它操作时出现了异常，那么这个会话对象的数据就是不健全的，这类脏数据很令人头疼。
                    // 此处我试图通过硬编码的方式进行数据回滚！
                    String sessionId = TransmittableThreadLocalHelper.getEnvironment().get(authTokenKey);
                    if (StringUtils.isNotEmpty(sessionId)) {
                        // 创建会话失败，删除已经创建的临时会话凭据
                        HttpSessionManager.deleteCurrentTerminalSession(loginName, clientDetails.getClientIpAddress(), clientDetails.getClientUserProfile(), sessionId);
                        cacheHandler.delete(sessionId, SessionCachePathEnum.SESSION_DATA_PATH);
                    }

                    throw new UtilityException(ex);
                }
            }
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 移除Session [注销]
     * <p>
     * 退出登录接口的调用受权限控制模块的管理，调用时要求提供token；
     * 无效的token不允许被访问！
     */
    @Override
    public boolean removeSession() throws UtilityException {
        try {
            // 获取当前HTTP请求对象
            HttpServletRequest request = ServletHelper.getRequest();

            String loginName = null, clientIpAddress = null, flagManagedProfile = null, accessToken = null;
            HttpSession httpSession = request.getSession(false);
            if (Objects.nonNull(httpSession)) {
                // 获取当前会话映射的登录名
                loginName = Optional.ofNullable((String) httpSession.getAttribute(SessionConstant.SESSION_LOGIN_KEY))
                        .orElse(StringUtils.EMPTY);
                // 获取当前会话映射的远程客户端设备标识
                clientIpAddress = Optional.ofNullable((String) httpSession.getAttribute(ApplicationConstant.REMOTE_CLIENT_IP_KEY))
                        .orElse(StringUtils.EMPTY);
                // 获取当前会话映射的远程客户端用户空间
                flagManagedProfile = Optional.ofNullable((String) httpSession.getAttribute(SessionConstant.CLIENT_USER_PROFILE_KEY))
                        .orElse(StringUtils.EMPTY);
                // 尝试获取请求头中的token
                accessToken = request.getHeader(authTokenKey);
            }

            if (super.removeHttpSession(request)) { // 如果此处可以注销成功，则上面的这3个属性是可以信任的
                // 将会话标识与客户端真实IP的映射关系从redis缓存中解除绑定
                HttpSessionManager.deleteCurrentTerminalSession(loginName, clientIpAddress, flagManagedProfile, accessToken);
                log.debug("会话标识【{}】与终端显示层【{}】的映射关系已从redis缓存中清除", accessToken, clientIpAddress);

                return true;
            }

            return false;
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 暴力移除会话[强制注销/强制下线]
     * <p>
     * 在同一台设备上，可用的终端是有限的，用户切换账号时后端需要主动负责将设备IP+用户空间映射的用户会话给注销掉，
     * 而不是让别人手动退出后重新输入账号、密码后才可以登录。
     * <p>
     * 该方法不应被调用，而应该由前端通过参数`conflictSessionId`主动调用退出登录的接口，然后才能再次调用登录的接口！
     */
    @Override
    @Deprecated
    public Boolean removeSessionByForce(@NonNull String conflictSessionId) throws UtilityException {
        try {
            Map<String, String> sessionMapResult = cacheHandler.getHash(conflictSessionId, SessionCachePathEnum.SESSION_DATA_PATH);
            String loginName = sessionMapResult.getOrDefault(
                    HttpSessionManager.buildAttributeCacheKey(SessionConstant.SESSION_LOGIN_KEY), StringUtils.EMPTY);
            String clientIpAddress = sessionMapResult.getOrDefault(
                    HttpSessionManager.buildAttributeCacheKey(ApplicationConstant.REMOTE_CLIENT_IP_KEY), StringUtils.EMPTY);
            String flagManagedProfile = sessionMapResult.getOrDefault(
                    HttpSessionManager.buildAttributeCacheKey(SessionConstant.CLIENT_USER_PROFILE_KEY), StringUtils.EMPTY);

            // 会话对象属性数据格式纠正
            loginName = StringHelper.trim(loginName, "\"");
            clientIpAddress = StringHelper.trim(clientIpAddress, "\"");
            flagManagedProfile = StringHelper.trim(flagManagedProfile, "\"");

            // 移除会话数据
            HttpSessionManager.deleteCurrentTerminalSession(loginName, clientIpAddress, flagManagedProfile, conflictSessionId);
            // Spring-Session出于安全性考量，内部源码封装的好多对象以及api均不允许在外部访问
            // 对于一些特殊的应用场景，最粗暴的办法就是直接访问redis集群并删除对应的会话对象数据，但不确定这样做是否会导致额外的风险！
            // 假如不赞同这样的做法，我们可以拿着此处的SessionId主动调用注销本地会话的接口，也就是借助spring-session支持的方式删除指定的会话对象！
            HttpSession session = ServletHelper.getRequest().getSession(false);
            if (Objects.nonNull(session) && session.getId().equalsIgnoreCase(conflictSessionId)) {
                session.invalidate();
            } else {
                cacheHandler.delete(conflictSessionId, SessionCachePathEnum.SESSION_DATA_PATH);
                cacheHandler.delete(conflictSessionId, SessionCachePathEnum.SESSION_EXPIRES_PATH);
            }

            return true;
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 判断当前登录操作是否与已有会话对象冲突
     *
     * @return 返回已经存在的会话对象标识
     */
    @Override
    public String checkSessionConflict(@NonNull String loginName) throws UtilityException {
        try {
            String flagManagedProfile = HttpSessionManager.readClientUserProfile(ServletHelper.getRequest());
            String clientIpAddress = TransmittableThreadLocalHelper.getEnvironment().get(ApplicationConstant.REMOTE_CLIENT_IP_KEY);

            String signPrincipal = cacheHandler.getHashValue(clientIpAddress, SessionCachePathEnum.SESSION_PROFILE_PATH, flagManagedProfile);
            if (StringUtils.isEmpty(signPrincipal)) {
                return StringUtils.EMPTY;
            }
            if (loginName.equalsIgnoreCase(signPrincipal)) {
                return StringUtils.EMPTY;
            }

            String uniqueTokenKey = HttpSessionManager.buildUniqueTokenKey(signPrincipal, clientIpAddress, flagManagedProfile);
            return cacheHandler.getValue(uniqueTokenKey, SessionCachePathEnum.SESSION_UNEXPIRED_PATH);
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 获取当前会话标识
     * <p>
     * 获取当前登录用户接口的调用受权限控制模块的管理，调用时要求提供token；
     * 无效的token不允许被访问！
     */
    @Override
    public HttpSession getSession() throws UtilityException {
        try {
            // 获取当前HTTP请求对象
            HttpServletRequest request = ServletHelper.getRequest();
            return super.getHttpSession(request);
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 设置属性值
     */
    @Override
    public void setAttribute(@NonNull String name, Object value) throws UtilityException {
        try {
            if (StringUtils.isBlank(name) || value == null) {
                return;
            }

            String tokenValue = TransmittableThreadLocalHelper.getEnvironment().get(authTokenKey);
            if (StringUtils.isEmpty(tokenValue)) {
                tokenValue = this.getSession().getId();
            }

            String cacheValue;
            if (value instanceof CharSequence) {
                cacheValue = ((CharSequence) value).toString();
            } else {
                cacheValue = objectMapper.writeValueAsString(value);
                cacheValue = objectMapperForRedis.writeValueAsString(JSONUtil.parseObj(cacheValue));
            }

            String cacheKey = DynamicCacheKeyBuilder.createUniqueCacheKey(tokenValue, SessionCachePathEnum.SESSION_DATA_PATH);
            String hashName = HttpSessionManager.buildAttributeCacheKey(name);
            // 警告：此处不允许再次对会话数据进行刷新，否则有可能导致spring-session过期机制在运行时产生紊乱
            cacheHandler.putHash(cacheKey, hashName, cacheValue);
            // 这个API在执行时，如果更新缓存成功就会重新设置一个有效期。二次封装，请不要干预spring-session会话数据的过期机制，有可能导致未知风险。
            // cacheHandler.putHash(tokenValue, SessionCachePathEnum.SESSION_DATA_PATH, hashName, cacheValue);
        } catch (Exception e) {
            String sessionId = ServletHelper.getRequest().getHeader(authTokenKey);
            log.error("设置当前会话对象(session-id：{})的属性 {} 时发生异常", sessionId, name);

            throw new UtilityException(e);
        }
    }

    /**
     * 设置属性值
     */
    @Override
    public void setAttribute(@NonNull StandardCacheKey cacheKeyEnum, Object value) throws UtilityException {
        try {
            setAttribute(cacheKeyEnum.getKey(), value);
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 移除属性
     */
    @Override
    public void removeAttribute(@NonNull String name) throws UtilityException {
        try {
            if (StringUtils.isBlank(name)) {
                return;
            }

            String tokenValue = TransmittableThreadLocalHelper.getEnvironment().get(authTokenKey);
            if (StringUtils.isEmpty(tokenValue)) {
                tokenValue = this.getSession().getId();
            }

            String hashName = HttpSessionManager.buildAttributeCacheKey(name);
            cacheHandler.removeHashEntry(tokenValue, SessionCachePathEnum.SESSION_DATA_PATH, Collections.singletonList(hashName));
        } catch (Exception e) {
            String sessionId = ServletHelper.getRequest().getHeader(authTokenKey);
            log.error("移除当前会话对象(session-id：{})的属性({})失败", sessionId, name);

            throw new UtilityException(e);
        }
    }

    /**
     * 移除属性
     */
    @Override
    public void removeAttribute(@NonNull StandardCacheKey cacheKeyEnum) throws UtilityException {
        try {
            removeAttribute(cacheKeyEnum.getKey());
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 获取属性值
     */
    @Override
    public String getAttribute(@NonNull String name) throws UtilityException {
        try {
            if (StringUtils.isBlank(name)) {
                return StringUtils.EMPTY;
            }

            String tokenValue = TransmittableThreadLocalHelper.getEnvironment().get(authTokenKey);
            if (StringUtils.isEmpty(tokenValue)) {
                tokenValue = this.getSession().getId();
            }

            String hashName = HttpSessionManager.buildAttributeCacheKey(name);
            String cacheValue = cacheHandler.getHashValue(tokenValue, SessionCachePathEnum.SESSION_DATA_PATH, hashName);

            if (JSONUtil.isTypeJSONArray(cacheValue)) {
                JSONArray jsonArray = JSONUtil.parseArray(cacheValue);
                String cachedObjectType = String.valueOf(jsonArray.get(0));
                if (Arrays.asList(JSONObject.class.getName(), JSONArray.class.getName()).contains(cachedObjectType)) {
                    return JSONUtil.toJsonStr(jsonArray.get(1));
                } else {
                    log.error(String.format("不能处理%s类型的JSON数组", String.valueOf(jsonArray.get(0))));
                    throw new SystemInterrupterException(ApiStatusEnum.ERR_A0421);
                }
            }

            return cacheValue;
        } catch (Exception e) {
            // 尝试获取请求头中的token
            String accessToken = ServletHelper.getRequest().getHeader(authTokenKey);
            log.error("获取当前会话对象(session-id：{})的属性({})失败", accessToken, name);

            throw new UtilityException(e);
        }
    }

    /**
     * 获取属性值
     */
    @Override
    public String getAttribute(@NonNull StandardCacheKey cacheKeyEnum) throws UtilityException {
        try {
            return getAttribute(cacheKeyEnum.getKey());
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 获取属性值
     */
    @Override
    public <T> T getAttribute(@NonNull StandardCacheKey cacheKeyEnum, Class<T> clazz) throws UtilityException {
        try {
            String cacheValue = getAttribute(cacheKeyEnum);
            return objectMapper.readValue(cacheValue, clazz);
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 获取属性值
     */
    @Override
    public <T> T getAttribute(@NonNull StandardCacheKey cacheKeyEnum, TypeReference<T> type) throws UtilityException {
        try {
            String cacheValue = getAttribute(cacheKeyEnum);
            return objectMapper.readValue(cacheValue, type);
        } catch (Exception e) {
            throw new UtilityException(e);
        }
    }

    /**
     * 获取所有的属性名列表
     */
    @Override
    public List<String> getAttributeNames() throws UtilityException {
        try {
            String tokenValue = TransmittableThreadLocalHelper.getEnvironment().get(authTokenKey);
            if (StringUtils.isEmpty(tokenValue)) {
                tokenValue = this.getSession().getId();
            }

            // 获取缓存服务器的会话对象数据【优化措施：此处应该返回Set<String>而不必返回Map<String, String>】
            Set<String> keySet = cacheHandler.getHash(tokenValue, SessionCachePathEnum.SESSION_DATA_PATH).keySet();
            return Lists.newArrayList(keySet);
        } catch (Exception e) {
            // 尝试获取请求头中的token
            String accessToken = ServletHelper.getRequest().getHeader(authTokenKey);
            log.error("获取当前会话对象(session-id：{})的属性列表失败", accessToken);

            throw new UtilityException(e);
        }
    }

}
